Skip to content

6 ERP Integration

F-WebShop

Introduction

Enterprise Resource Planning or ERP is a software that controlls various processes that are essential to running a business, including inventory and order management, accounting, human resources, customer relationship management (CRM), and other. One of the main benefits of ERP systems is single database so when change is made in one part of the system all other elements can see this change. Among the most important parts of ERP and any ecommerce solution are products, brand, categories of products, orders and similar elements so it makse a lot of sense that changes made in ERP shoud be refleced in F-Webshop.

Every synchronization with ERP systems starts with ERPIntegrationPlugin. ERPIntegrationPlugin contains list of Symfony commands. When a command is started F-Webshop dispatches custom event for retrieving data. ERP specific plugin (example FLeaderPlugin) listens for this event and when it is dispatched it retrieves data from ERP and returns it to original command. Command then creates pagination and cache files used in synchronization. In the process of prepearing data for sync another event is dispatched, this is mappings event. Every ERP system has its own data structure and nameing scheme so we use this event to map F-Webshop fields to fields received from ERP. ERPIntegrationPlugin then formats data and persists it in database.

Currently it is possible to synchronize following elements:
- Brands
- Categories
- Products
- Prices
- Quantities
- Users
- Images
- Attributes
- Attribute values
- Product attribute values
- Price lists
- Quantity rebate
- Actions rebate
- Plugins

Starting commands

Every command used in synchronization is stored within ERPIntegrationPlugin and they can be called individually but best practice is to use doSync.sh script to call them. Almost all commands store additional temporary files and doSync.sh manages these files automatically. doSync.sh receives multiple parameters that determine its beheviour. Parameters are: categories, brands, products, products_delete, prices, pricelists, quantities, images, attributes, attribute_values, product_attribute_values, sync_users, sync_quantites_rebate, sync_actions_rebate, plugins and additional_options. Depending on the parameters specific commands will be executed. If no parameters are specified all commands will be started. For example we can start synchronization using:

 src/Plugins/ErpIntegrationPlugin/Command/doSync.sh

to sync synchronize everything or

 src/Plugins/ErpIntegrationPlugin/Command/doSync.sh brands

to synchronize only brands.

As an example we can take a look at how brand synchronization is executed. First max_pages and page parameters are set. Since it is highly recommended to use pagination data these two parameters enable this. Page represents current page and max_page is the last page of synchronization. Next empire-cli:erp:sync-brands command is started with page parameter. This command then fetches data from ERP and prepares pagination data. Command the writes pagination data to brandPagination.sh and doSync.sh reads this data and sets max_pages parameter. If max_pages is greater than page parameter command will be executed again. Additional commands can be executed to process all of synchronized data after synchronization of all elements. Example of souch command is empire-cli:generate-translations-for-entities command that generates translations after brands are synchronized.

...

if ${syncBrands}; then  
     max_pages=1  
     page=1  
     while (($page <= $max_pages)); do  
         php ${console_path} empire-cli:erp:sync-brands ${page}  
         if [ ${page} == 1 ] && [ -f ${0%/*}/TmpFiles/brandPagination.sh ]; then  
             source ${0%/*}/TmpFiles/brandPagination.sh 2> /dev/null  
         fi

        ((page++))  
    done  
    php ${console_path} empire-cli:generate-translations-for-entities -b --env=prod --no-debug  
fi

...

Synchronization command

Next we will take a look at example of synchronization command. First command prepares page parameter for pagination.

$page = $this->input->getArgument('page') ?? null;

After this command dispatches custom ErpIntegrationEvents::GET_BRANDS_EVENT event

$data = $this->dispatchEventWithResult(ErpIntegrationEvents::GET_BRANDS_EVENT, array('page' => $page));

Returned data contains rows and page keys. Rows key is data form ERP and page key is number of pages to be synchronized. Data about number of pages is than stored in pagination file. After this command fetches data already stored in F-Webshop:

$results = $this->get('alligator.brand_manager')->getAllBrandsIdAndCode();  
$brandCodes = array_column($results, 'code');

This is done so system knows if brand is new or it is existing brand that should be updated. Next command fetches mappings data (this will be explained in detail later) from specific ERP plugin:

$mappings = $this->getMappings('brand');  
$codeKey = $this->getMappingsValueForKey('code', $mappings);  
$brandNameKey = $this->getMappingsValueForKey('brandName', $mappings);

This data maps field from ERP to field from F-Webshop. With ERP data and mappings data custom sql can be formated and executed:

$this->bnQuery .= 'WHEN code="'.$code.'" THEN "'.$brandName.'" ';  
$this->sQuery .= 'WHEN code="'.$code.'" THEN '.$slug.' ';  
$this->pnQuery .= 'WHEN code="'.$code.'" THEN '.$parentBrandName.' '; 
...
$this->update();  
$this->insert();

When it all comes together command looks something like this:

try {  
    $page = $this->input->getArgument('page') ?? null;

    /** get data from erp */  
    $data = $this->dispatchEventWithResult(ErpIntegrationEvents::GET_BRANDS_EVENT, array('page' => $page));  
    if (!$data['rows']) {  
        $this->writeSuccess('No brands to sync! Sync successful!');

        return;  
    }

    /** if it is first page write max_pages parameter to shell script */  
    self::setPage($page, $data['page'], ERPTmpFiles::BRAND_LIST_PAGINATION_PATH);

    /** get all brand erp codes - determines if update or insert should be done */  
    $results = $this->get('alligator.brand_manager')->getAllBrandsIdAndCode();  
    $brandCodes = array_column($results, 'code');

    $em = $this->getContainer()->get('doctrine')->getManager();  
    $this->connection = $em->getConnection();

    /** get mappings and keys */  
    $mappings = $this->getMappings('brand');  
    $codeKey = $this->getMappingsValueForKey('code', $mappings);  
    $brandNameKey = $this->getMappingsValueForKey('brandName', $mappings);

    /** create query for every item */  
    foreach ($data['rows'] as $row) {  
        $code = $row[$codeKey];

        $nameEvent = $this->dispatchEventWithResult('erp.integration.custom.brand.name.logic', array('row' => $row, 'existingBrands' => $results, 'newBrands' => $data['rows'], 'brandNameKey' => $brandNameKey));  
        $brandName = $nameEvent ?: $row[$brandNameKey];

        $slug = $this->connection->quote(ErpFunctions::urlize($brandName), \PDO::PARAM_STR);  
        $parentBrandName = $this->getValueFromErpData('parentBrandName', $row, $mappings);  
        $parentBrandName = $parentBrandName ? '"'.$parentBrandName.'"' : 'NULL';


        if (!\in_array($code, $brandCodes, false)) {  
            /** there is no brand */  
            $this->insertQuery .= '("'.$brandName.'", "REGULAR", "'.$slug.'", "'.$code.'", 0, '.$parentBrandName.'),';

        } else {  
        /** update existing brands */  
        $this->bnQuery .= 'WHEN code="'.$code.'" THEN "'.$brandName.'" ';  
        $this->sQuery .= 'WHEN code="'.$code.'" THEN '.$slug.' ';  
        $this->pnQuery .= 'WHEN code="'.$code.'" THEN '.$parentBrandName.' ';

        $this->erpCodes .= '"'.$code.'",';  
        }  
    }  
    $this->update();  
    $this->insert();

    $this->writeSuccess(sizeof($data['rows']).' brands updated. Page: '.$page.' of '.$data['page']);  
} catch (\Exception $e) {  
    $this->writeError($e->getFile().' ('.$e->getLine().'): '.$e->getMessage());  
}

Getting data from ERP

As we saw first thing all commands do is retrieve data from ERP. This is done using standard Symfony event system. For every entity that is being synchronized custom event is dispatched (ex. ErpIntegrationEvents::GET_BRANDS_EVENT). List of all events is stored within ErpIntegrationEvents class.

/**
  * Class ErpIntegrationEvents - Defines all event used to get data and execute custom events        
  */  
final class ErpIntegrationEvents    {

   /**    
    * Get products event 
    * @var string    
    */  
   public const GET_PRODUCTS_EVENT = 'erp.integration.get.products';    
...

Each ERP system has it own specific plugin. These plugins are dependent on ERPIntegration plugin and are used to implement specific communication to ERP. One of examples of this plugin is FLeader plugin. FLeader has a number of event listeners that are responsible for providing data from ERP. When listener is activated it sends API request to ERP server. This data is processed and returned to original command. ERP specific plugins can work in many different ways, for example data can be retrieved from API request or using direct connection to ERP database. Lets take a look at an example.

FLeader has custom FleaderGetDataListener listener.

services:  
    fleader.get.data.listener:  
        class: Plugins\FLeaderPlugin\Business\Event\FleaderGetDataListener  
        tags:  
            - { name: kernel.event_listener, event: erp.integration.get.categories, method: getCategoriesFromFleader }  
            - { name: kernel.event_listener, event: erp.integration.get.products, method: getProductsFromFleader }  
            - { name: kernel.event_listener, event: erp.integration.get.products.deleted, method: getDeletedProductsFromFleader }
            - { name: kernel.event_listener, event: erp.integration.get.brands, method: getBrandsFromFleader }
...

As we can see FleaderGetDataListener listens for multiple events. All of these events are used to provide data to synchronization commands. So when erp.integration.get.brands event is dispatched getBrandsFromFleader function will be executed:

/**  
 * Get brands * @param EventWithResult $event  
 * @throws \Exception  
 */
public function getBrandsFromFleader(EventWithResult $event)  
{  
    $params = $event->getParam();  
    $api = new ApiBrands($params['page'] ?? 1, 100);

    $data = json_decode($api::fetch(), true)['data'];  
    if ($data == NULL) {  
        throw new \Exception('Error while getting brand data from FLeader');  
    }

    $event->setResult(array(  
        'rows' => $data['rows'],  
        'page' => $data['last_page']  
    ));  
}

This function create instance of ApiBrands class that internally creates request to api route for ERP. This data is decoded and formatted in appropriate response. As we can see in this example, ERP specific plugins should use pagination while working with data. This reduces load and speeds up the process.

Preparing F-Webshop data

Next thing all commands do is prepare data already stored in F-Webshop. For example when brand synchronization is started F-Webshop retrieves code and id of all brand already stored in database. This is done so the system knows if it should do update or insert new data.

/** get all brand erp codes - determines if update or insert should be done */  
$results = $this->get('alligator.brand_manager')->getAllBrandsIdAndCode();  
$brandCodes = array_column($results, 'code');

Since synchronization system uses pagination this will be executed for every page of data. I order to speed up and reduce load cache files are used. ErpCacheUtil is responsible for retrieving and cacheing this data.

/**     
 * Class ErpCacheUtil - Helper class designed to provide cached data from database 
 */
class ErpCacheUtil    
{    
    /** @var ProductManager */
    private $productManager;

    ...

    /** @var array */    
    private $attributeValueErpCodes = [];

    ...

    /**    
     * Get all erp codes and ids of products   
     * Use file as cache, if there is no file create one       
     * @param string $filename    
     * @return array    
     * @throws \Exception    
     */  
    public function setAllProductsIdAndCode($filename = ERPTmpFiles::PRODUCT_ID_AND_ERP_CODES_FILE_PATH)
    {    
        try {    
           if (!file_exists($filename)) {    
                $dbData = $this->productManager->getAllProductsErpCodes();

                /** create string from every row and write it to file */    
                $this->createArrayCacheFile($dbData, 'productIdAndErpCodes', $filename, function ($row) {    
                    return '"'.$row['erpCode'].'" => '.$row['id'].',';    
                });    
           }

           include $filename;

           return $this->productIdAndErpCodes;    
        } catch (\Exception $e) {    
           throw new \Exception(sprintf('Product sync error! Error while creating id and erp codes cache file: %s', $e->getMessage()));    
        }    
    }

    ...

As we can se setAllProductsIdAndCode function reads data from database and stores it in specified file. So next time information abouth products is needed it will be read from file instead of querying database again.

Pagination

Since there is a posibiliti of very large amounts of data beeng transfered betwean ERP and F-Webshop and within F-Webshop itself it is best practice to use pagination. ERP Data should be alweys separated in multiple requests. All commands used in synchronization expect that ERP returns number of pages to by synced. This data is than used to controll synchronization. Titles of all files used to store pagination and cached data are located in ERPTmpFiles class, and those files are located in ERPTmpFiles directory within ERPIntegrationPlugin.

/**     
 * Class ERPTmpFiles. Class containing all file names that are used in synchronization   
 * These files include files for caching data and pagination among other files   
 */  
class ERPTmpFiles    {    
    /**    
     * Path to directory of files * @var string    
     */  
    public const TMP_FILES_DIRECTORY = __DIR__.'/../../Command/TmpFiles';

    /**  
     * Store how many pages we need to sync * @var string         */  
    public const PRODUCT_PAGINATION_FILE_PATH = self::TMP_FILES_DIRECTORY.'/productPagination.sh';

    ...

If these files are not managed properly some parts of synchronization can misbehave. So in order for synchronization to work properly before start of synchronization these files should be removed. This process of cleanup is managed by doSync.sh script.

Getting mappings

Data in ERP is specific to every ERP system so mappings information is used to connect fields from F-Webshop to fields in ERP. This data is stored in ERP specific plugins and is received using events. All events for mapping data are stored in ErpIntegrationEvents class. As example we can take a look at product mappings data.

/**     
 * Return mapping information for product   
 * @param EventWithResult $event    
 */    
public function getProductMappings(EventWithResult $event)    
{    
   $data = [    
        'id' => [    
            'property' => 'id',    
            'ignoreUpdate' => true    
        ],    
        'productName' => 'name',    
        'erpCode' => [    
            'property' => 'id',    
            'ignoreUpdate' => true    
        ],    
        'updatedAt'   => [
            'default'      => new \DateTime(),
        ],
        'tags' => [    
            'property' => 'tags',    
            'function' => function ($param) { return explode(',', $param); },    
            'default' => []
        ],
        'unit_of_measure'  => [
            'property' => 'unit',
            'ignore'   => true
        ],
        ...
    ];

    $event->setResult($data);    
}

Simplest form of mappings if just defining that productName filed in F-Webshop is connected to name filed in ERP data:

'productName' => 'name',

Product name needs to be updated every time sinchronization is executed, but this is not the case for all fields. For example id of product should not be changed when we are updating data. When we do not want to update a filed we can set ignoreUpdate flage, like this:

'id' => [    
    'property' => 'id',    
    'ignoreUpdate' => true    
],

Other fields may not alweays retarn value from ERP but we can set them default values by using default key. Example is updateadAt filed that shoud be alweays set tu current data:

'updatedAt'   => [
    'default'      => new \DateTime(),
],

If case we need to adjust data from ERP we can define closure functions. For example FLeader returns product tags as comma separated string but F-Webshop uses simple arrays to define tags. In this case we can define function kay and define function that converts input string to array:

tags' => [  
  'property' => 'tags',  
  'function' => function ($param) { return explode(',', $param); },  
  'default' => []  
],

Custom events

Some of the commands also dispatch additional events for data manipulation. For example each implementation can have its own logic for product flags. So when product entity is created there is special event that allows implementation plugin to further alter data before it is saved in database. Examples of these events are:

erp.integration.user.update.custom.logic
erp.integration.images.custom.logic
erp.custom.product.quantity.actions

events.

Since multiple implementations can have same ERP system we need to create implementation specific plugin. This plugin should contain data specific for implementation, souche as API addres, databease connections or frequecies for synchorization.

Synchronization fields

Brands

List of brand used to group products

FIELD TYPE DESCRIPTION
id int(11) Primary key
code varchar(255) Unique code of brand. If ERP does not return value use ID from ERP
brand_name varchar(255) Name of brand
position varchar(255) Position of brand in sorted list
is_visible tinyint(1) Position of brand in sorted list
seo_title varchar(255) Brand seo title
seo_description longtext Brand seo description
seo_keywords longtext Brand seo keywords

Categories

List of product categories

FIELD TYPE DESCRIPTION
id int(11) Primary key
name int(11) Category name
description longtext Category description
display tinyint(1) Enable disable category visibility
hide_from_menu tinyint(1) Show/hide category from menu
root_id int(11) Root category of the category tree
parent_id int(11) Category parent node
code varchar(255) Category code
lft int(11) Category left property for tree structure
lvl int(11) Category level property for tree structure
rgt int(11) Category right property for tree structure
slug varchar(254) Category slug
meta_title longtext Category meta title
meta_description longtext Category meta description
meta_keywords longtext Category meta keywords

Products

List of products

FIELD TYPE DESCRIPTION
id int(11) Primary key
brand_id int(11) Product brand
erp_code varchar(255) Code to connect product with ERP representation of entity
product_name varchar(255) Name of product
description longtext Description of product
description_html longtext Description of product as HTML
slug varchar(255) Product slug
permalink varchar(255) Product permalink, needs to be unique on database level
sku varchar(255) Sku of product
tags longtext Product tags, specific keywords(DC2Type:simple_array)
tax_value double Tax value for product
vat_value double Vat value for product
arrival_date datetime Date when product will be available
is_new tinyint(1) Product is new
on_sale tinyint(1) Product is on sale
on_total_sale tinyint(1) Product is on total sale
on_action tinyint(1) Product is on action
damaged_good tinyint(1) Product is damaged
keywords longtext Product keywords array
additional_keywords longtext Product additional keywords for search
product_search_options longtext Contains all product specific search terms
seo_title varchar(255) SEO title for product
seo_description longtext SEO description for product
seo_keywords longtext SEO keywords for product
unit_of_measure_id int(11) Unit of measure
package_id int(11) Type of packing
unit_of_measure_weight_id int(11) Unit of measure for weight
unit_of_measure_qty_per_package_id int(11) Unit of measure for quantity per package
view_count int(11) Number of times the product is viewed
sales int(11) Number of times the product is sold
width double Product width
height double Product height
depth double Product depth
weight double Product weight with weight of its packaging
net_weight double Product weight without weight of its packaging
qty_per_package double Number of products per package

Prices

Synchronization of prices will insert new row in table and update current price on product

FIELD TYPE DESCRIPTION
product int(11) ERP code of product
value int(11) Price for product

Quantities

Synchronization of quantities will update SimpleProduct table. Apart from product quantities we can define values for product flags using this table

FIELD TYPE DESCRIPTION
base_product_id int(11) Product
numberInStock int(11) Available quantity, total quantity decreased by reserved quantity
min_quantity int(11) Minimal quantity that can be added to cart
total_quantity int(11) Total quantity in stock, including reserved quantity
reserved_quantity int(11) Reserved quantity
min_quantity_light_value int(11) Minimal quantity light value. Defines value that separates red and yellow flags
max_quantity_light_value int(11) Maximal quantity light value. Defines value that separates yellow and green flags

Users

List of users

FIELD TYPE DESCRIPTION
id int(11) Primary key
address_id int(11) Address of user
username varchar(255) Username of user
email varchar(255) Email address of user
enabled tinyint(1) Is user enabled
locked tinyint(1) Indicates if user account is locked
first_name varchar(255) User first name
last_name varchar(255) Last name of user
user_code varchar(255) User erp code
user_secondary_code varchar(255) User code
phone_numbers longtext Json array of phone numbers
gdpr_confirmed tinyint(1) Indicates if user has confirmed gdpr cookies
title varchar(128) Title of user
default_payment_type varchar(255) Payment which will be preselected on checkout
default_shipping_provider_id int(11) Shipping provider which will be preselected on checkout

Address of user

Each user must have at least one address. When you are synchronizing users make sure you add address to every user

FIELD TYPE DESCRIPTION
id int(11) Primary key
user_id int(11) User
active tinyint(1) Is address still active
phone_number longtext Phone number of address owner(DC2Type:json_array)
country varchar(255) Address country
township varchar(255) Address township
street varchar(255) Address street
street_number varchar(255) Address street number
apartment_number varchar(255) Address apartment number
floor_number varchar(255) Address floor number
city varchar(255) Address city
postcode varchar(255) Address postcode
erp_codeIndex varchar(255) Unique identifier for address. Used for storing address's erp code

Images

Product images. Each image can have its code so no duplicates occur

FIELD TYPE DESCRIPTION
id int(11) Primary key
product_id int(11) Product of image
name varchar(255) File name
code varchar(255) Unique identifier for image
priority int(11) Position of image. Used for sort images in products
not_resizable tinyint(1) Property that enables or disables image resizing on save
watermark_image tinyint(1) Use watermark over image or not
position varchar(255) Image position

Attributes

List of all attributes. Color, size, gender are all examples of attributes

FIELD TYPE DESCRIPTION
id int(11) Primary key
name varchar(255) Name of attribute
code int(11) Unique identifier for attribute
display_to_user tinyint(1) Should display on frontend
priority int(11) Position of attribute in attribute set
type varchar(255) Attribute type (CHECK, RADIO, SELECT)
attribute_type varchar(255) Attribute type (information type, price type)

Attribute values

Specific attribute values. For example red, green, blue are all part of color attribute

FIELD TYPE DESCRIPTION
id int(11) Primary key
attribute_id int(11) Attribute
code int(11) Unique identifier for attribute
priority int(11) Position in attribute
value varchar(255) Value name
color varchar(255) Color of button to display
display_text tinyint(1) Should display value name

Product attribute values

Group of attributes in relation to products create Product attribute values. Product attribute value can have its own specific data (ex. quantity and sku)

FIELD TYPE DESCRIPTION
id int(11) Primary key
product_id int(11) Product
code int(11) Code to connect product attribute values with ERP representation of entity
sku varchar(255) Sku of product attribute values
erp_code varchar(255) Code to connect product attribute values with ERP representation of entity
tax_value int(11) Tax value for product attribute values
quantity int(11) Available quantity, total quantity decreased by reserved quantity
total_quantity int(11) Total quantity in stock, including reserved quantity
reserved_quantity int(11) Reserved quantity
min_quantity int(11) Minimal quantity that can be added to cart
quantity_step int(11) Product attribute values quantity to add to cart
default_pav tinyint(1) Default product attribute value
display_to_user tinyint(1) Show product attribute values fo user
keywords longtext Product attribute values keywords
additional_keywords longtext Product attribute values additional keywords

Price list

List of available price lists

FIELD TYPE DESCRIPTION
id int(11) Primary key
name varchar(255) Name of pricelist
currency varchar(255) Currency string of pricelist
is_default tinyint(1) Is pricelist default
erp_code varchar(255) Erp code of pricelist
discount double Base discount
discounts longtext Array of quantity dependent discounts

Price list item

Relation between product and price list

FIELD TYPE DESCRIPTION
id int(11) Primary key
pricelist_id int(11) Pricelist
product_id int(11) Product
price double Price for item
action_price double Price for action

Product group

Group of products with specific discounts

FIELD TYPE DESCRIPTION
id int(11) Primary key
name varchar(255) Name of group
is_active tinyint(1) Is group active
date_from datetime Date from which the group is available
date_to datetime Date to which the group is available
keywords varchar(255) Products can be filtered by keywords
products longtext Final array of all ids of products in group
custom_products longtext (DC2Type:array)
base_discount int(11) Discount for quantity 1
discounts longtext Array of discounts
priority int(11) Priority of group
base_fixed_discount int(11) Discount for quantity 1
fixed_discounts_array longtext Array of fixed discounts

Product group discount

Group of products with specific discounts for specific users and price lists

FIELD TYPE DESCRIPTION
id int(11) Primary key
product_group_id int(11) Product group
limit_to_price_list_id int(11) Pricelist
base_discount int(11) Discount for quantity 1
discounts longtext Array of discounts
base_fixed_discount int(11) Discount for quantity 1
fixed_discounts_array longtext Discount for quantity 1